Skip to content

Vegas mode: reclaim dead space and pace the rotation - #423

Open
ChuckBuilds wants to merge 12 commits into
mainfrom
feat/vegas-mode-density
Open

Vegas mode: reclaim dead space and pace the rotation#423
ChuckBuilds wants to merge 12 commits into
mainfrom
feat/vegas-mode-density

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Problem

On a wide panel Vegas mode spent much of its time showing black. The conversion that makes this concrete: at 50px/s on a 512px display, one display width of blank is 10.2 seconds.

Measured on a 512×64 rig before any changes:

mean ink coverage 42.7%
fully blank 5.9%
worst contiguous blank 4.8s
full rotation 414s (6 cycles)
plugins per cycle 3

Five root causes, each verified against the running service:

  1. A full display width of black at the start of every cycle. ScrollHelper.create_scrolling_image unconditionally prepended display_width as an "initial gap". Only the multi-display-sync path skipped it, so with sync off nothing did — 10.2s of black per rotation.
  2. Full-canvas captures entered the ticker with their blank margins. Plugins without get_vegas_content() are captured off a display-sized canvas. of-the-day drew 35px of "No Data" on a 512px canvas (92% blank); youtube-stats had 142px of content with 185px of black either side. A trim helper existed but ran only on the scroll_helper path.
  3. Cycle transitions blanked the panel, then blocked. A blank frame was pushed deliberately, then the next cycle was composed synchronously — including plugin.update_data() network calls on the render path. Measured 84ms at best, 4.8s at worst, all of it black.
  4. buffer_ahead doubled as the cycle size. A 21-plugin install showed 3 plugins per cycle, ~7 cycles to come around.
  5. separator_width was applied between every image, not at plugin boundaries. The F1 scoreboard returns 116 images (one per standings row) which it renders 4px apart internally; Vegas forced 32px between each. The width budget also didn't count those gaps, so the plugin occupied far more of the panel than its budget allowed.

Result

before after
mean ink coverage 42.7% 69.4%
fully blank 5.9% 0%
reads as empty 13.6% 0%
worst blank stretch 4.8s 0s
full rotation 414s 123s
plugins per cycle 3 6

Live examples from the rig: of-the-day 512px → 65px (87% reclaimed), countdown 512px → 87px, ledmatrix-stocks 7360px capped to a rotating 1536px window, f1-scoreboard 11 of 116 rows per cycle with the rest deferred. Plugins that genuinely fill the screen (odds-ticker, tide-display) were correctly left untouched.

Approach

  • src/vegas_mode/geometry.py — numpy column-ink primitives shared by the trimmer and the audit tool, so the number reported is the number acted on. A Python per-column loop over a 17,000px strip is far too slow for the render path.
  • Trimming runs on all three content paths. Only outer edges are cropped — interior blank columns are the plugin's own layout (logo left, score right) and closing them would corrupt the design rather than reclaim dead space. A plugin drawing on a non-black background is inherently unaffected, since every column carries ink.
  • create_scrolling_image gains an explicit lead_gap, still defaulting to display_width so the dozen-plus standalone-ticker callers behave identically. Only Vegas passes lead_in_width (default 0). Chosen over resetting scroll_position, which would leave total_scroll_width overstated and need matching fixes to the cycle-complete threshold.
  • Cycle end holds the last frame instead of blanking, so the recompose reads as a brief freeze rather than the display switching off.
  • Composition groups by plugin. Each plugin's rows are pre-joined with intra_plugin_gap (default 8) into one block, so ScrollHelper sees one item per plugin and separator_width lands only at handoffs.
  • Width budget defers rather than discards. A rotation offset advances per fetch so later rows appear on subsequent cycles; single oversized images are cut at the nearest blank column so the cut misses glyphs.

Configurability

Every new knob is exposed in Display → Vegas Scroll, plus min_cycle_duration, max_cycle_duration and dynamic_duration_enabled which already existed in code but were reachable only by hand-editing config.json. Save and validation verified end-to-end (200 persisting, 400 on out-of-range).

On the metric

Worth flagging for reviewers: my first metric was a "fully blank" scan (≥95% black viewport). It reported 0.4% and badly understated the problem, because two full-width segments with mid-canvas content never fully blank the viewport — they hold it at ~28%, which still reads as an empty panel. window_coverage_stats grades every viewport position by how much ink it carries; that is the number that tracks perceived dead time. Both live in geometry.py with tests pinning the distinction.

Testing

  • 94 new tests across test_vegas_geometry.py and test_vegas_density.py, asserting exact column layouts (row row [8px] row row [32px] row row) rather than just totals.
  • Full suite: 1237 passed. Four failures are pre-existing and reproduce identically on a clean tree (verified by stashing) — test_display_dirty_tracking, test_web_api::test_get_system_status, and two in test_state_reconciliation.
  • scripts/dev/vegas_audit.py is included as a repeatable tool; it drives the real PluginAdapter and ScrollHelper and produced every number above. It runs off-hardware, so it is safe alongside a live display.
  • Validated on real hardware throughout (512×64, 21 plugins, live MLB data). FPS unchanged at ~41-43.

Known remaining

Cycle transitions still freeze ~3.5s while the next cycle's content is fetched. Fixing that needs background prefetch, deliberately deferred: the fallback-capture path mutates the shared display_manager.image, and racing that against the render loop risks torn frames and visible flashes. The contained version backgrounds only the native paths (where nearly all the time is spent) and is better reviewed separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

Summary by CodeRabbit

  • New Features
    • Added expanded Vegas Scroll controls for spacing, trimming, sizing, cycle pacing, overflow handling, and dynamic cycle duration, including updated UI fields and validation.
    • Introduced configurable lead-in blank gap behavior and an offline audit tool to measure Vegas Mode density/coverage.
  • Bug Fixes
    • Improved continuous scrolling to extend the strip smoothly with correct wrap timing and no unnecessary blank-frame output; refined sub-pixel blending and scroll extension/caching behavior.
  • Tests
    • Expanded end-to-end and geometry/continuous-scroll test coverage for trimming, budgeting, overflow modes, lead-in semantics, and config round-tripping.

On a wide panel Vegas mode spent much of its time showing black. At 50px/s
on a 512px display, one display width of blank is 10.2 seconds, which makes
several long-standing behaviours expensive:

- ScrollHelper prepended a full display width of black as an "initial gap",
  charged once per cycle — 10.2s of black at the start of every rotation.
- Plugins without get_vegas_content() are captured off a full-display canvas,
  so their blank margins entered the ticker too. Measured: of-the-day drew
  35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of
  content with 185px of black either side. Only the scroll_helper path had
  any trimming.
- Cycle transitions deliberately pushed a blank frame and then recomposed
  synchronously: 84ms at best, 4.8s at worst, every millisecond of it black.
- buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3
  plugins per cycle and took ~7 cycles to come around.
- separator_width was applied between every image rather than at plugin
  boundaries, so a per-row ticker like the F1 scoreboard (116 images, which
  it renders 4px apart internally) got a 32px chasm between each row — and
  the width budget didn't count those gaps, so the plugin quietly occupied
  far more of the panel than intended.

Changes:

- src/vegas_mode/geometry.py: numpy column-ink primitives shared by the
  trimmer and the audit tool, so the number reported is the number acted on.
  A Python per-column loop over a 17,000px strip is far too slow for the
  render path.
- PluginAdapter trims every content path, not just scroll_helper. Only outer
  edges are cropped: interior blank columns are the plugin's own layout
  (logo left, score right) and closing them would corrupt the design. A
  plugin on a non-black background is inherently unaffected.
- ScrollHelper.create_scrolling_image takes an explicit lead_gap, still
  defaulting to display_width so the many standalone-ticker callers are
  unchanged. Vegas passes lead_in_width (default 0).
- Cycle end holds the last rendered frame instead of blanking, turning the
  recompose into a brief freeze rather than the panel switching off.
- plugins_per_cycle (default 6) is split from buffer_ahead, which goes back
  to being only a prefetch low-water mark.
- max_plugin_width_ratio (default 3x display width) caps one plugin's share
  of a cycle. Overflow is deferred, not discarded: a rotation offset advances
  each fetch so later rows appear on subsequent cycles. Single oversized
  images are cropped at a blank column so the cut misses glyphs.
- Composition groups images by plugin: rows are joined by intra_plugin_gap
  (default 8) and separator_width applies only between plugins. The width
  budget now counts those gaps.
- Plugin data updates no longer run on the Vegas render path.

All new settings are user-configurable in Display -> Vegas Scroll, including
min/max cycle duration and dynamic duration, which previously existed in code
but were reachable only by hand-editing config.json.

Measured with scripts/dev/vegas_audit.py on a 512x64 panel:

  mean ink coverage    42.7% -> 69.4%
  fully blank           5.9% -> 0%
  reads as empty        13.6% -> 0%
  worst blank stretch    4.8s -> 0s
  full rotation          414s -> 123s
  plugins per cycle         3 -> 6

Note the metric choice: a "fully blank" scan (>=95% black viewport) reported
only 0.4% and badly understated the problem, because two full-width segments
with mid-canvas content never fully blank the viewport — they hold it at ~28%.
window_coverage_stats grades every viewport position by how much ink it
carries, which is what tracks perceived dead time.

Known remaining: cycle transitions still freeze ~3.5s while the next cycle is
fetched. Fixing that needs background prefetch, which is deferred because the
fallback-capture path mutates the shared display_manager.image and racing it
against the render loop risks torn frames.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Vegas Mode gains configurable geometry, width budgeting, grouped composition, continuous scrolling, dead-space metrics, updated buffering and transitions, UI/API settings, an offline audit tool, and extensive geometry and integration tests.

Changes

Vegas density and scroll behavior

Layer / File(s) Summary
Vegas configuration surface
config/config.template.json, src/vegas_mode/config.py, web_interface/blueprints/api_v3.py, web_interface/templates/v3/partials/display.html, test/test_vegas_density.py
Adds Vegas spacing, trimming, cycle sizing, lead-in, duration, and width-ratio settings with parsing, validation, persistence, UI controls, and tests.
Content geometry and width budgeting
src/vegas_mode/geometry.py, src/vegas_mode/plugin_adapter.py, src/display_manager.py, src/plugin_system/*, test/test_vegas_density.py
Adds ink measurement, trimming, blank-cut selection, viewport statistics, narrowed rendering contexts, width budgets, rotation, cropping, and capture-state handling.
Buffered plugin composition and continuous cycles
src/common/scroll_helper.py, src/vegas_mode/stream_manager.py, src/vegas_mode/render_pipeline.py, src/vegas_mode/coordinator.py, test/test_scroll_helper_continuous.py
Groups plugin rows, applies measured gaps and lead-in spacing, buffers by cycle capacity, prefetches and drains deferred content, extends strips while preserving position, and retains the last frame at cycle boundaries.
Offline density audit
scripts/dev/vegas_audit.py
Adds offline plugin rendering, per-plugin measurements, cycle analysis, JSON/text reporting, and optional PNG output.
Geometry and integration validation
test/test_vegas_geometry.py, test/test_vegas_density.py, test/test_scroll_helper_continuous.py
Validates geometry, trimming, separation, blank cuts, continuous strip behavior, capture safety, deferred draining, cycle timing, and configuration bounds.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConfigUI
  participant API
  participant VegasModeCoordinator
  participant PluginAdapter
  participant RenderPipeline
  participant ScrollHelper
  participant DisplayManager
  ConfigUI->>API: submit Vegas scroll settings
  API->>VegasModeCoordinator: apply updated configuration
  VegasModeCoordinator->>PluginAdapter: replace config and invalidate cache
  RenderPipeline->>PluginAdapter: prefetch or request finalized content
  PluginAdapter-->>RenderPipeline: return trimmed and budgeted content
  RenderPipeline->>ScrollHelper: compose or extend scrolling cycle
  ScrollHelper-->>RenderPipeline: preserve scroll position
  RenderPipeline->>DisplayManager: render current non-empty frame
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the PR’s main themes of reclaiming dead space and improving rotation pacing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/vegas-mode-density

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Jul 29, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 314 complexity · 2 duplication

Metric Results
Complexity 314
Duplication 2

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/vegas_mode/config.py (1)

74-74: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

max_cycle_duration default disagrees with the template and the new UI copy.

Code default is 600, config/config.template.json now ships 240, and the new Max Cycle Time help text says "Default: 240". Users reading either surface get a different answer than a config without the key. Align the dataclass default (and template) on one value.

🔧 Suggested alignment
-    max_cycle_duration: int = 600  # Maximum seconds per full cycle
+    max_cycle_duration: int = 240  # Maximum seconds per full cycle

Also update from_config()'s max_cycle_duration fallback to match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/config.py` at line 74, Align the max_cycle_duration default
across the configuration dataclass, config template, and from_config() fallback,
using 240 seconds to match the UI help text. Update the max_cycle_duration
declaration and its fallback handling without changing other configuration
behavior.
🧹 Nitpick comments (3)
src/vegas_mode/render_pipeline.py (1)

154-158: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused loop variable flagged by Ruff (B007).

♻️ Rename to `_plugin_id`
-            for plugin_id, images in grouped:
+            for _plugin_id, images in grouped:
                 total_rows += len(images)
                 blocks.append(self._join_plugin_rows(images))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/render_pipeline.py` around lines 154 - 158, Rename the unused
plugin_id loop variable in the grouped iteration within the render pipeline to
_plugin_id, leaving total_rows accumulation and _join_plugin_rows processing
unchanged.

Source: Linters/SAST tools

src/common/scroll_helper.py (1)

268-273: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale invariant comments now that lead_gap is configurable.

Lines 268-272 (and the same claim at Line 329 and Lines 587-589) still state the image "already includes display_width pixels of blank padding at the start (added by create_scrolling_image)". With lead_gap=0 from Vegas mode that is no longer true. Behaviour is unaffected here, but the next reader will reason from a false premise — and calculate_dynamic_duration() still adds a full display_width of travel on top of total_scroll_width for the same reason.

📝 Suggested wording
-        # Calculate required total distance: total_scroll_width only.
-        # The image already includes display_width pixels of blank padding at the start
-        # (added by create_scrolling_image), so once scroll_position reaches
-        # total_scroll_width the last card has fully scrolled off the left edge.
-        # Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
+        # Calculate required total distance: total_scroll_width only.
+        # Any leading blank margin (lead_gap, display_width by default but 0 in
+        # Vegas mode) is already part of total_scroll_width, so once
+        # scroll_position reaches it the last card has scrolled off the left
+        # edge. Adding display_width here would cause 1-2 extra wrap-arounds.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/common/scroll_helper.py` around lines 268 - 273, Update the stale
invariant comments near required_total_distance, the corresponding comment
around line 329, and the comment near line 587 to reflect that the leading blank
padding depends on configurable lead_gap and may be zero in Vegas mode. Also
review calculate_dynamic_duration() and remove its unconditional extra
display_width travel, preserving distance calculations based on the actual
configured image padding and total_scroll_width.
scripts/dev/vegas_audit.py (1)

124-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

join_rows re-implements RenderPipeline._join_plugin_rows verbatim.

The docstring itself says "matching RenderPipeline._join_plugin_rows" — that's an explicit coupling this audit tool depends on for accuracy, but nothing enforces it. If the production join logic changes (e.g. gap handling, height calc), this copy will silently drift and the audit will report numbers that no longer reflect production, undermining the tool's own purpose ("pulls each one's content through the real PluginAdapter... composes... through the real ScrollHelper").

Consider extracting this into a small shared helper (e.g. in src/vegas_mode/geometry.py or a new render_utils.py) that both RenderPipeline and this script import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/dev/vegas_audit.py` around lines 124 - 136, Extract the duplicated
row-composition logic from join_rows into a shared production helper, then
update both join_rows and RenderPipeline._join_plugin_rows to call it. Preserve
the existing single-image behavior, nonnegative gap handling, RGB canvas
creation, width/height calculations, and paste ordering so the audit uses the
exact production implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/dev/vegas_audit.py`:
- Around line 208-211: Update the PluginAdapter construction in the audit setup
to pass the already loaded Vegas configuration as its second argument. Keep the
existing display_manager and manager initialization unchanged so trimming,
finalization, and width budgeting use the same config.json settings as
production.

In `@web_interface/blueprints/api_v3.py`:
- Around line 966-970: Update the numeric_fields entries for vegas_scroll_speed,
vegas_target_fps, and vegas_buffer_ahead to match the bounds enforced by
VegasModeConfig.validate() and the UI: allow scroll speed through 200, require
target FPS to start at 30, and cap buffer_ahead at 5. Leave the other numeric
field ranges unchanged.

---

Outside diff comments:
In `@src/vegas_mode/config.py`:
- Line 74: Align the max_cycle_duration default across the configuration
dataclass, config template, and from_config() fallback, using 240 seconds to
match the UI help text. Update the max_cycle_duration declaration and its
fallback handling without changing other configuration behavior.

---

Nitpick comments:
In `@scripts/dev/vegas_audit.py`:
- Around line 124-136: Extract the duplicated row-composition logic from
join_rows into a shared production helper, then update both join_rows and
RenderPipeline._join_plugin_rows to call it. Preserve the existing single-image
behavior, nonnegative gap handling, RGB canvas creation, width/height
calculations, and paste ordering so the audit uses the exact production
implementation.

In `@src/common/scroll_helper.py`:
- Around line 268-273: Update the stale invariant comments near
required_total_distance, the corresponding comment around line 329, and the
comment near line 587 to reflect that the leading blank padding depends on
configurable lead_gap and may be zero in Vegas mode. Also review
calculate_dynamic_duration() and remove its unconditional extra display_width
travel, preserving distance calculations based on the actual configured image
padding and total_scroll_width.

In `@src/vegas_mode/render_pipeline.py`:
- Around line 154-158: Rename the unused plugin_id loop variable in the grouped
iteration within the render pipeline to _plugin_id, leaving total_rows
accumulation and _join_plugin_rows processing unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 27075b10-e5c2-4d13-9756-2824bb26e483

📥 Commits

Reviewing files that changed from the base of the PR and between e2acbfb and 8d57a74.

📒 Files selected for processing (14)
  • config/config.template.json
  • scripts/dev/vegas_audit.py
  • src/common/scroll_helper.py
  • src/plugin_system/testing/visual_display_manager.py
  • src/vegas_mode/config.py
  • src/vegas_mode/coordinator.py
  • src/vegas_mode/geometry.py
  • src/vegas_mode/plugin_adapter.py
  • src/vegas_mode/render_pipeline.py
  • src/vegas_mode/stream_manager.py
  • test/test_vegas_density.py
  • test/test_vegas_geometry.py
  • web_interface/blueprints/api_v3.py
  • web_interface/templates/v3/partials/display.html

Comment thread scripts/dev/vegas_audit.py Outdated
Comment thread web_interface/blueprints/api_v3.py Outdated
ChuckBuilds and others added 2 commits July 28, 2026 20:26
Flagged by Codacy (F401). Any, Dict and List are all still used.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
Both from review feedback on #423.

The web API's accepted ranges disagreed with VegasModeConfig.validate(),
which is what actually gates Vegas starting:

  scroll_speed      1-100  -> 1-200   (a slider value of 150 returned 400)
  separator_width   0-500  -> 0-128
  target_fps        1-200  -> 30-200
  buffer_ahead      1-20   -> 1-5

The three loose ones were the dangerous direction: the value saved with a
200, then VegasModeCoordinator.start() failed validation with only a log
line, so the ticker silently never ran. The UI already matched validate() in
all four cases, so the API was the odd one out.

test_vegas_api_bounds_match_validate parses the numeric_fields map out of
api_v3 and asserts every bound against validate(), plus that validate()
accepts both endpoints and rejects just outside them, so these cannot drift
apart again. That test immediately caught a missing upper bound on
min_plugin_width, now added — unbounded it would drop every segment and
leave a blank ticker.

Separately, vegas_audit.py constructed PluginAdapter without the config, so
it fell back to VegasModeConfig() defaults and would report trimming and
width-budget behaviour that differed from the user's config.json. It now
passes the loaded config exactly as the coordinator does. This is the same
class of drift the explicit lead_gap and grouping arguments already guard
against. Output is unchanged on a rig whose config matches the defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
@ChuckBuilds

Copy link
Copy Markdown
Owner Author

Thanks — both findings were real and are fixed in d69dfbb.

Validation range mismatches. Confirmed, and there was a fourth you didn't flag: separator_width accepted 0–500 against validate()'s 0–128. Aligned all four to validate(), since the UI already agreed with it in every case — the API was the odd one out:

setting was now
scroll_speed 1–100 1–200
separator_width 0–500 0–128
target_fps 1–200 30–200
buffer_ahead 1–20 1–5

You correctly identified the asymmetry in how these fail. The loose ones are the worse direction: the value saved with a 200, then VegasModeCoordinator.start() failed validation with only a log line, so the ticker silently never ran. Verified against the running rig — scroll_speed=150 went 400 → 200, and buffer_ahead=20 / target_fps=10 went 200 → 400.

Added TestApiBoundsMatchValidate, which parses the numeric_fields map out of api_v3.py via AST and asserts every bound against validate(), plus that validate() accepts both endpoints and rejects just outside them. That test immediately earned its place by catching a missing upper bound on min_plugin_width — unbounded, it would drop every segment and leave a blank ticker. Now capped at 512.

Audit config plumbing. Also correct. PluginAdapter(display_manager) fell back to VegasModeConfig() defaults, so the audit would report trimming and width-budget behaviour that differed from the user's config.json. It now passes the loaded config exactly as the coordinator does. This is the same class of drift the explicit lead_gap and grouping arguments already guard against, so it should have been caught by that reasoning — good spot. Output is unchanged on my rig because its config happens to match the defaults, which is precisely why it went unnoticed.

Trimming reclaims blank margins but cannot compact a layout that genuinely
spans the display — a five-column forecast, a progress bar drawn at 100%
width, a stat block with the panel's whole width between its elements. Those
need the plugin to make different layout decisions, which means telling it the
screen is narrower while it renders.

DisplayManager.render_size() presents a smaller logical canvas for the
duration of a Vegas content fetch, reusing the same _LogicalMatrix
indirection double-sided mode already relies on so plugins see a consistent
size from every accessor. Plugins that size themselves from matrix.width need
no changes at all; one that wants to be explicit can read the new
BasePlugin.get_vegas_render_width().

Width is a percentage so a single setting travels across panel sizes:
vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual
plugin's config. Measured on a 512x64 panel with real data:

  ledmatrix-weather   1536px -> 576px   (forecast becomes narrow cards)
  youtube-stats        353px -> 199px   (2% blank left, so genuinely compact)
  geochron             453px -> 153px   (ink density rises to 100%)
  ledmatrix-flights    950px -> 740px

The youtube-stats figure is the clearest evidence the layout itself changed
rather than being cropped: at full width the content had to be trimmed from
512px to 353px, whereas at 40% it arrives with almost no blank to reclaim.

Row spacing is now measured rather than added. A flat gap gets it wrong in
both directions at once — content drawn flush to its own edges ends up nearly
touching (reported for recent sports scores, which sat 8px apart), while
content already carrying wide margins gets pushed even further out.
separation_gap() measures the blank each pair already has and adds only the
shortfall, up to min_content_separation (default 24). intra_plugin_gap stays
as a floor applied regardless.

Two tests shipped in the previous commit encoded the old flat-gap arithmetic
and are updated to the measured semantics, including one renamed to reflect
that zero intra_plugin_gap alone no longer butts rows together.

Also fixes a real bug found while testing: the harness display manager had no
render_size(), and because the adapter catches broadly that surfaced as "no
content" rather than an error, silently dropping five plugins. Added the
context to VisualTestDisplayManager for parity, and _render_at() now degrades
to a no-op on any display manager lacking it, so a third-party or older
harness loses the narrowing rather than the content.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
test/test_vegas_density.py (2)

411-486: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

EXPECTED omits render_width_pct and min_content_separation despite both having full validate()+API bounds.

render_width_pct (10–100) and min_content_separation (0–256) are fully bounded on both sides in VegasModeConfig.validate() and in the api_v3.py numeric_fields table shown, yet TestApiBoundsMatchValidate.EXPECTED doesn't include them. Given this exact class of drift ("an additional mismatch for separator_width") was already found once in this PR, extending EXPECTED to cover these two closes an easy blind spot in the same regression net. lead_in_width, min_cycle_duration, and max_cycle_duration are correctly excluded since validate() doesn't enforce a matching bound for them.

♻️ Extend EXPECTED coverage
     EXPECTED = {
         'scroll_speed': (1, 200),
         'separator_width': (0, 128),
         'intra_plugin_gap': (0, 128),
+        'render_width_pct': (10, 100),
+        'min_content_separation': (0, 256),
         'target_fps': (30, 200),
         'buffer_ahead': (1, 5),
         'trim_threshold': (0, 254),
         'content_padding': (0, 128),
         'min_plugin_width': (0, 512),
         'plugins_per_cycle': (1, 50),
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 411 - 486, Extend
TestApiBoundsMatchValidate.EXPECTED with render_width_pct set to (10, 100) and
min_content_separation set to (0, 256), matching the bounds enforced by
VegasModeConfig.validate() and the API numeric_fields map. Leave lead_in_width,
min_cycle_duration, and max_cycle_duration excluded.

260-277: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate _pipeline helper across two test classes.

TestPluginBoundaryGaps._pipeline and TestMeasuredSeparation._pipeline are byte-for-byte identical (same FakeStream/DM doubles and RenderPipeline construction). Worth hoisting to a shared module-level fixture/helper to avoid the two drifting apart later.

Also applies to: 666-683

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 260 - 277, Hoist the duplicated
_pipeline helper shared by TestPluginBoundaryGaps and TestMeasuredSeparation
into a module-level helper or fixture. Preserve the existing FakeStream and DM
doubles, configuration handling, and RenderPipeline construction, then update
both test classes to reuse the shared implementation.
src/display_manager.py (1)

539-591: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

render_size() relies on an unenforced capture_mode() precondition.

The docstring states this is "Only meaningful inside capture_mode()" because it swaps the shared self.image/self.matrix/self.draw in place without a lock. update_display() guards against exactly this race by checking self._capture_mode_active before touching the panel — but that guard only works if every caller of render_size() also entered capture_mode() first, and nothing in render_size() itself verifies that. If a future caller (or a refactor of PluginAdapter._render_at) invokes render_size() without pairing it with capture_mode(), a concurrent background thread calling update_display() (explicitly documented as possible from plugin "live" refresh threads) could push the narrowed, mid-render buffer to the physical panel — a visible flash of the wrong plugin's cropped content across the whole panel. Failing fast here (per the project's Fail Fast principle) would catch a future misuse immediately instead of surfacing as an intermittent hardware glitch.

🛡️ Guard against use outside capture_mode()
         real_matrix = self.matrix
         prev_image = getattr(self, 'image', None)
         prev_draw = getattr(self, 'draw', None)
+
+        if not self._capture_mode_active:
+            logger.warning(
+                "render_size() called outside capture_mode(); the render "
+                "loop could push a mid-swap buffer to hardware"
+            )
As per coding guidelines, "Validate inputs and handle errors early (Fail Fast principle)".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/display_manager.py` around lines 539 - 591, Add an early guard at the
start of render_size() that verifies self._capture_mode_active is enabled before
changing self.image, self.draw, or self.matrix; raise the project’s established
misuse/error type when render_size() is called outside capture_mode(). Preserve
the existing no-op and restoration behavior for valid capture-mode usage.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/display_manager.py`:
- Around line 539-591: Add an early guard at the start of render_size() that
verifies self._capture_mode_active is enabled before changing self.image,
self.draw, or self.matrix; raise the project’s established misuse/error type
when render_size() is called outside capture_mode(). Preserve the existing no-op
and restoration behavior for valid capture-mode usage.

In `@test/test_vegas_density.py`:
- Around line 411-486: Extend TestApiBoundsMatchValidate.EXPECTED with
render_width_pct set to (10, 100) and min_content_separation set to (0, 256),
matching the bounds enforced by VegasModeConfig.validate() and the API
numeric_fields map. Leave lead_in_width, min_cycle_duration, and
max_cycle_duration excluded.
- Around line 260-277: Hoist the duplicated _pipeline helper shared by
TestPluginBoundaryGaps and TestMeasuredSeparation into a module-level helper or
fixture. Preserve the existing FakeStream and DM doubles, configuration
handling, and RenderPipeline construction, then update both test classes to
reuse the shared implementation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b91beb7-8d5b-4f6b-a55e-1d1fe7ee12e5

📥 Commits

Reviewing files that changed from the base of the PR and between 8d57a74 and 616d21c.

📒 Files selected for processing (13)
  • config/config.template.json
  • scripts/dev/vegas_audit.py
  • src/display_manager.py
  • src/plugin_system/base_plugin.py
  • src/plugin_system/testing/visual_display_manager.py
  • src/vegas_mode/config.py
  • src/vegas_mode/geometry.py
  • src/vegas_mode/plugin_adapter.py
  • src/vegas_mode/render_pipeline.py
  • test/test_vegas_density.py
  • test/test_vegas_geometry.py
  • web_interface/blueprints/api_v3.py
  • web_interface/templates/v3/partials/display.html
🚧 Files skipped from review as they are similar to previous changes (7)
  • config/config.template.json
  • test/test_vegas_geometry.py
  • src/vegas_mode/render_pipeline.py
  • src/vegas_mode/config.py
  • web_interface/blueprints/api_v3.py
  • scripts/dev/vegas_audit.py
  • src/vegas_mode/plugin_adapter.py

ChuckBuilds and others added 2 commits July 29, 2026 09:35
Three fixes, the first a regression from lead_in_width defaulting to 0.

get_visible_portion wraps: once scroll_position + display_width passes the end
of the strip it fills the right of the frame from the *head* of the same strip.
So the final display_width of travel showed the cycle's first plugin re-entering
on the right while its last plugin exited on the left, and the recompose that
followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of
two plugins on screen at once, ending in a hard cut — reported as the ticker
"switching mid-scroll" from F1 to news.

That used to be invisible because the strip began with a full display_width of
blank, so the wrapped-in region was black. Removing that blank (it was 10s of
dead panel per cycle) exposed the wrap. Cycles now end one display width
earlier, before any wrapped content appears, clamped for strips no wider than
the display so they don't complete instantly and spin the recompose loop.

Verified on hardware: a 3936px strip now completes at 68.5s, exactly
(3936 - 512) / 50.

Second, auto_trim=False also skipped the width budget, which is an unrelated
concern — turning off margin cropping should not let one plugin hold the panel
for minutes. Seen in the field: the F1 scoreboard contributed 116 images and
14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget
now applies regardless of trimming; with it restored that cycle is 6,362px.

Third, the budget accounted for row gaps using the flat intra_plugin_gap while
the compositor had moved to measured separation, so it under-counted by up to
(min_content_separation - intra_plugin_gap) per row and a many-row plugin
overran its cap. Both now use the same separation_gap() rule, and a test
asserts the composed block fits the budget end to end rather than trusting the
two paths to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
A cut position after the last column is legitimate — _crop_to_budget asks for
min(start + budget, img.width), which equals the width whenever the remaining
strip is shorter than the budget. find_blank_cut clamped target to width but
then walked leftwards starting at target itself, so ink[width] raised
IndexError.

Caught on hardware: it killed the ledmatrix-stocks fetch, and because
_fetch_plugin_content catches broadly that surfaced as the plugin silently
contributing nothing for the cycle.

Only reachable on the second or later pass of the rotating window over a single
oversized image, which is why the existing tests missed it — they all exercised
the first pass, where start is 0 and start + budget is comfortably inside the
image. Added TestRotationAcrossMultipleCycles, which walks the window round
several times and asserts content is never lost, plus direct coverage of
find_blank_cut at and beyond the image edge.

Both bounds now stop at width - 1 so neither direction can index past the end.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
The width-budget crop snapped to the nearest blank column, and in rendered text
the gap between two characters is a single column. So a cut routinely landed
inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a
lone floating letter in the next cycle, positioned after whatever plugin
happened to precede it.

Measured on the clock-simple segment to confirm: its blank runs are
[1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which
find_blank_cut would happily have chosen.

Cuts now only land in a run of at least min_cut_gap blank columns (default 6),
which excludes letter spacing while still finding the gaps plugins put between
items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls
inside the budget the cut waits for the next one and overruns, because
splitting an item is worse than a slightly long segment.

Continuous content is treated differently on purpose: an image with no internal
gaps is a map or a chart, where any column is as good as another, so it is still
cut to the budget exactly. The gap rule protects discrete items; letting a solid
image escape the cap in its name would be wrong.

blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a
per-column Python loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (6)
src/vegas_mode/render_pipeline.py (2)

472-482: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two adjacent blocks guard on the same condition.

if result and self.sync_manager: is evaluated twice back to back; fold the lead-in positioning into the existing sync block.

♻️ Proposed merge
         if result and self.sync_manager:
             # When sync is active, start the leader past the lead-in gap so it
             # immediately shows content, leaving the follower on the blank gap
             # for a clean transition rather than near-end content wrapping
             # around. This tracks lead_in_width rather than assuming a full
             # display width of gap, which is no longer the default.
             self.scroll_helper.scroll_position = float(self.config.lead_in_width)
-
-        if result and self.sync_manager:
             # Signal follower that a new cycle started (triggers its own rebuild)
             self.sync_manager.send_new_cycle()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/render_pipeline.py` around lines 472 - 482, Merge the adjacent
`if result and self.sync_manager` blocks into one shared sync block, keeping the
`scroll_helper.scroll_position` lead-in assignment and
`self.sync_manager.send_new_cycle()` call in their current order and behavior.

210-236: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Docstring no longer matches the spacing rule.

Rows are spaced by separation_gap(target=min_content_separation, floor=intra_plugin_gap) — measured blank, not a flat intra_plugin_gap. The docstring still promises the latter.

As per coding guidelines, "Use docstrings for classes and complex functions".

♻️ Proposed doc fix
             A single image with the rows laid out left to right, separated by
-            ``intra_plugin_gap``. Returned unchanged when there is only one row,
-            which is the common case and avoids a pointless copy.
+            enough blank to reach ``min_content_separation`` of measured
+            separation, never less than ``intra_plugin_gap``. Returned unchanged
+            when there is only one row, which avoids a pointless copy.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/render_pipeline.py` around lines 210 - 236, Update the
_join_plugin_rows docstring to describe spacing rows using measured separation
via separation_gap, with min_content_separation as the target and
intra_plugin_gap as the floor, instead of claiming a flat intra_plugin_gap. Keep
the rest of the docstring accurate, including the single-row unchanged behavior.

Source: Coding guidelines

test/test_vegas_density.py (2)

1009-1022: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused word_strip spans (RUF059) at two sites. Both tests unpack the span list from word_strip() and never use it; discard it explicitly.

  • test/test_vegas_density.py#L1009-L1022: change img, spans = word_strip(...) to img, _ = ... and simplify {c for c in legal} to legal.
  • test/test_vegas_density.py#L1061-L1069: change img, spans = word_strip(...) to img, _ = ....
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 1009 - 1022, Discard the unused span
result from word_strip in both test_cut_lands_in_an_item_gap_not_between_letters
sites: test/test_vegas_density.py lines 1009-1022 and 1061-1069 should unpack
into img and _. At the anchor site, also simplify the legal-set assertion to
compare cut_width directly against legal.

Source: Linters/SAST tools


974-999: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two independent computations of letter positions.

Span ends are accumulated in the first loop while the paste loop recomputes sx + l_i * (letter_w + letter_gap). They agree today, but any change to spacing must be made twice. Painting inside the first loop (or deriving both from one helper) removes the duplication, and adding type hints matches the repo convention.

As per coding guidelines, "Use type hints for function parameters and return values".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 974 - 999, Update word_strip to use
a single letter-position calculation: paint each letter while accumulating its
position in the existing construction loop, and derive spans and image width
from that same accumulation instead of recomputing positions in a second loop.
Add type hints for all parameters and the tuple return value, preserving the
current spacing and rendered output.

Source: Coding guidelines

src/vegas_mode/geometry.py (2)

233-269: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wire or remove find_item_boundary.

find_item_boundary is only defined, while _crop_to_budget still computes gaps directly with blank_runs. Either route the budgeted crop through this helper so item-level blank columns are consistently selected, or remove it to keep the codebase aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/geometry.py` around lines 233 - 269, Update _crop_to_budget to
use find_item_boundary for selecting the cut column, reusing its min_run and
threshold behavior instead of computing gaps directly with blank_runs; preserve
the no-qualifying-gap path by leaving the image uncropped. If routing the crop
through the helper is not appropriate, remove find_item_boundary and its related
documentation instead.

195-230: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Python target is not declared, so prefer omitting zip strict=.

Adding strict=True only helps if the installed Python supports it; the project docs/scripts default to python3.13, but there is no explicit requires-python/target-version policy to make this refactor follow a runtime constraint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/geometry.py` around lines 195 - 230, Update the return
expression in blank_runs to omit any zip(strict=...) argument, preserving the
existing pairing of starts and ends. Do not introduce a Python-version
requirement or target policy; keep the vectorized run detection unchanged.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/test_vegas_density.py`:
- Around line 1024-1035: Update test_no_partial_letter_at_either_edge so the
cropped output requires both edge columns to be blank, while preserving the
out.width == img.width escape hatch for uncropped images. Replace the current
one-edge assertion logic with a conjunction that rejects any crop touching ink
at either edge.

---

Nitpick comments:
In `@src/vegas_mode/geometry.py`:
- Around line 233-269: Update _crop_to_budget to use find_item_boundary for
selecting the cut column, reusing its min_run and threshold behavior instead of
computing gaps directly with blank_runs; preserve the no-qualifying-gap path by
leaving the image uncropped. If routing the crop through the helper is not
appropriate, remove find_item_boundary and its related documentation instead.
- Around line 195-230: Update the return expression in blank_runs to omit any
zip(strict=...) argument, preserving the existing pairing of starts and ends. Do
not introduce a Python-version requirement or target policy; keep the vectorized
run detection unchanged.

In `@src/vegas_mode/render_pipeline.py`:
- Around line 472-482: Merge the adjacent `if result and self.sync_manager`
blocks into one shared sync block, keeping the `scroll_helper.scroll_position`
lead-in assignment and `self.sync_manager.send_new_cycle()` call in their
current order and behavior.
- Around line 210-236: Update the _join_plugin_rows docstring to describe
spacing rows using measured separation via separation_gap, with
min_content_separation as the target and intra_plugin_gap as the floor, instead
of claiming a flat intra_plugin_gap. Keep the rest of the docstring accurate,
including the single-row unchanged behavior.

In `@test/test_vegas_density.py`:
- Around line 1009-1022: Discard the unused span result from word_strip in both
test_cut_lands_in_an_item_gap_not_between_letters sites:
test/test_vegas_density.py lines 1009-1022 and 1061-1069 should unpack into img
and _. At the anchor site, also simplify the legal-set assertion to compare
cut_width directly against legal.
- Around line 974-999: Update word_strip to use a single letter-position
calculation: paint each letter while accumulating its position in the existing
construction loop, and derive spans and image width from that same accumulation
instead of recomputing positions in a second loop. Add type hints for all
parameters and the tuple return value, preserving the current spacing and
rendered output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 164c9ea6-d119-4b7d-be04-c5c0633ae958

📥 Commits

Reviewing files that changed from the base of the PR and between 616d21c and a1c528a.

📒 Files selected for processing (9)
  • config/config.template.json
  • src/vegas_mode/config.py
  • src/vegas_mode/geometry.py
  • src/vegas_mode/plugin_adapter.py
  • src/vegas_mode/render_pipeline.py
  • test/test_vegas_density.py
  • test/test_vegas_geometry.py
  • web_interface/blueprints/api_v3.py
  • web_interface/templates/v3/partials/display.html
🚧 Files skipped from review as they are similar to previous changes (6)
  • config/config.template.json
  • web_interface/templates/v3/partials/display.html
  • test/test_vegas_geometry.py
  • web_interface/blueprints/api_v3.py
  • src/vegas_mode/config.py
  • src/vegas_mode/plugin_adapter.py

Comment on lines +1024 to +1035
def test_no_partial_letter_at_either_edge(self):
# A split letter shows as a lit column touching the crop edge with the
# rest of its glyph missing. Requiring the edges to be blank is the
# simplest way to assert we cut inside a gap.
from src.vegas_mode.geometry import column_has_ink
img, _ = word_strip([9, 9, 9, 9, 9, 9])
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25,
min_cut_gap=6)
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
ink = column_has_ink(out)
assert not ink[0] or not ink[-1] or out.width == img.width, \
"crop edges land on ink, so a glyph was cut through"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assertion passes when one edge cuts through a glyph.

The comment states the requirement as "the edges to be blank", but not ink[0] or not ink[-1] is satisfied by a single blank edge — a crop whose right edge slices a letter still passes as long as the left edge is blank. Use and (with the out.width == img.width escape hatch) to actually pin the invariant.

💚 Proposed fix
-        assert not ink[0] or not ink[-1] or out.width == img.width, \
+        assert out.width == img.width or (not ink[0] and not ink[-1]), \
             "crop edges land on ink, so a glyph was cut through"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_no_partial_letter_at_either_edge(self):
# A split letter shows as a lit column touching the crop edge with the
# rest of its glyph missing. Requiring the edges to be blank is the
# simplest way to assert we cut inside a gap.
from src.vegas_mode.geometry import column_has_ink
img, _ = word_strip([9, 9, 9, 9, 9, 9])
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25,
min_cut_gap=6)
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
ink = column_has_ink(out)
assert not ink[0] or not ink[-1] or out.width == img.width, \
"crop edges land on ink, so a glyph was cut through"
def test_no_partial_letter_at_either_edge(self):
# A split letter shows as a lit column touching the crop edge with the
# rest of its glyph missing. Requiring the edges to be blank is the
# simplest way to assert we cut inside a gap.
from src.vegas_mode.geometry import column_has_ink
img, _ = word_strip([9, 9, 9, 9, 9, 9])
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25,
min_cut_gap=6)
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
ink = column_has_ink(out)
assert out.width == img.width or (not ink[0] and not ink[-1]), \
"crop edges land on ink, so a glyph was cut through"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 1024 - 1035, Update
test_no_partial_letter_at_either_edge so the cropped output requires both edge
columns to be blank, while preserving the out.width == img.width escape hatch
for uncropped images. Replace the current one-edge assertion logic with a
conjunction that rejects any crop touching ink at either edge.

ChuckBuilds and others added 2 commits July 29, 2026 13:33
The native content path only entered capture_mode when it was also narrowing
the canvas, so at full width — which is every plugin without a vegas_width_pct
override, i.e. most of them — a plugin calling update_display() while building
its Vegas content wrote straight to the hardware. That is a visible flash
mid-scroll, and it lines up with the flash reported at cycle transitions, when
several plugins are fetched back to back.

Suppression is now unconditional; the narrowing context stays separate because
it is already a no-op at full width.

Both contexts are reached through helpers that degrade to nullcontext when the
display manager lacks them. That matters more than it looks: the adapter's
handlers are deliberately broad, so an AttributeError from a missing context
does not surface as an error — it surfaces as the plugin contributing nothing.
Making the call unconditional without this turned 44 tests red for exactly that
reason, all of them reporting lost content rather than the real cause.

The test double now provides capture_mode and render_size too, so tests
exercise the real contexts instead of silently taking the degraded path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
A cycle used to be a discrete strip that got replaced: motion stopped, every
pixel was substituted at once, and the next group started with the viewport
already full. That is the freeze, the flash and the jump.

The strip is now extended rather than replaced. ScrollHelper gains
append_content(), which adds items on the right without touching
scroll_position or total_distance_scrolled, so motion continues and the next
group simply arrives from the right. Because completion is measured against
total_scroll_width, extending also defers completion — there is no longer a
cycle boundary to see.

drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded
however long Vegas runs (observed 5,000-11,000px against an unbounded strip
otherwise). It shifts total_distance_scrolled and total_scroll_width together so
the completion arithmetic is unchanged, and refuses to run while the viewport is
wrapping: wrapping reads the head of the strip into the right of the frame, so
trimming the head there would visibly change the picture. A test caught that.

Groups are prepared off the render thread. The constraint is that the canvas and
the matrix proxy are process-wide mutable state, so narrowing or capturing
through them from another thread would corrupt the frame the render loop is
pushing. get_content() therefore takes offscreen_only: the background thread uses
only paths that avoid the canvas, and anything needing it is marked and picked up
on the render thread. That puts the expensive work (native renders of leaderboard
and baseball cards, seconds each) in the background and leaves the cheap work
(display capture, 40-600ms) in the foreground.

DisplayManager's capture flag is now thread-local. As a shared flag, a background
capture would have suppressed the render loop's own frame pushes for its
duration, freezing the panel precisely when the point was to avoid a freeze.

Canvas-bound plugins are drained one at a time rather than as a batch: six at
once held the render thread for 1.75s. Drains are also spaced by two seconds
while the lookahead is healthy, since taking them back to back turns one long
stall into a run of short ones. When the strip is genuinely running short the
throttle is ignored, because content matters more than smoothness there.

Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart,
lookahead holding at 1,200-3,500px, no errors.

Set continuous_scroll false to restore the swap behaviour; the old path is intact.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
test/test_vegas_density.py (2)

1156-1156: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant import inside the class body.

contextmanager is already imported at module level (line 6); this class-scoped re-import also leaks a contextmanager class attribute on RecordingDM.

♻️ Proposed cleanup
-        from contextlib import contextmanager
-
         `@contextmanager`
         def capture_mode(self):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` at line 1156, Remove the redundant class-scoped
contextmanager import from RecordingDM, keeping the existing module-level import
for any class methods that use it and preventing contextmanager from becoming a
RecordingDM class attribute.

1491-1511: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Throttle tests depend on wall-clock and on the default extend_threshold_screens.

test_drains_are_spaced_when_lookahead_is_healthy and test_urgent_drain_ignores_the_throttle both hinge on DEFERRED_DRAIN_INTERVAL versus real time.time(), and on whether the seeded 600px strip is judged urgent under the config default. If either default changes these silently invert. Consider pinning extend_threshold_screens explicitly in _pipeline(...) for these two tests and monkeypatching the clock rather than relying on real elapsed time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 1491 - 1511, Make the throttle tests
deterministic by explicitly setting extend_threshold_screens in _pipeline for
test_drains_are_spaced_when_lookahead_is_healthy and
test_urgent_drain_ignores_the_throttle, choosing values that preserve healthy
versus urgent behavior. Replace reliance on real time.time() with a
monkeypatched clock and advance it explicitly around DEFERRED_DRAIN_INTERVAL so
the expected deferred and immediate drain outcomes remain stable if defaults or
test timing change.
src/common/scroll_helper.py (1)

651-714: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Each append reallocates the whole strip on the render thread.

np.concatenate plus Image.fromarray means every extension costs a full copy of the strip (peak = old + new simultaneously). Bounded by drop_scrolled_prefix, so it should stay under ~1 MB at 512×64, but on the Pi this lands on the render path. If extension hitches show up in profiling, consider growing into a pre-allocated buffer with slack columns and tracking a logical width instead of resizing exactly. No change needed now.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/common/scroll_helper.py` around lines 651 - 714, The reviewer notes a
potential render-thread performance concern in append_content, but explicitly
requests no change now. Leave the np.concatenate and Image.fromarray
implementation unchanged; do not add a preallocated buffer, slack capacity, or
logical-width refactor.
src/display_manager.py (1)

529-537: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Capture state is a boolean, so nested capture_mode() releases suppression too early.

capture_mode() sets the flag then clears it unconditionally in finally. If two capture scopes ever nest on the same thread (e.g. an adapter path that wraps _capture() around code that itself enters capture), the inner exit re-enables hardware writes while the outer scope is still capturing — the exact flash this mechanism exists to prevent. Note the new test double in test/test_vegas_density.py models this as a capture_depth counter, which suggests depth semantics are the intended contract.

🛡️ Proposed fix — depth-counted thread-local
     `@property`
     def _capture_mode_active(self) -> bool:
         """True while the calling thread is capturing content off-screen."""
-        return getattr(self._capture_state, 'active', False)
+        return getattr(self._capture_state, 'depth', 0) > 0
 
     `@_capture_mode_active.setter`
     def _capture_mode_active(self, value: bool) -> None:
-        self._capture_state.active = bool(value)
+        depth = getattr(self._capture_state, 'depth', 0)
+        self._capture_state.depth = depth + 1 if value else max(0, depth - 1)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/display_manager.py` around lines 529 - 537, Update the capture state used
by _capture_mode_active and capture_mode() from a boolean to a per-thread
nesting depth, incrementing on entry and decrementing on exit without allowing
premature suppression release. Keep _capture_mode_active true while depth is
greater than zero, including across nested capture scopes, and preserve the
existing thread-local behavior.
src/vegas_mode/coordinator.py (1)

309-324: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

cycles_completed now counts strip extensions, and live updates no longer reach visible content.

Two observations on this branch:

  • A successful extend_scroll_content() increments stats['cycles_completed'], but an extension is not a cycle — the reported status will inflate steadily. render_pipeline.stats['extensions'] already tracks this; consider a separate extensions counter here.
  • The should_recompose() / hot_swap_content() path is skipped in continuous mode, so a live score change on a plugin already composed into the strip stays stale until that plugin rotates back round. Worth confirming that's the intended trade for continuous motion.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/coordinator.py` around lines 309 - 324, Update the
continuous-scroll branch in the coordinator flow so successful
render_pipeline.extend_scroll_content() calls do not increment
stats['cycles_completed']; track extensions separately or rely on
render_pipeline.stats['extensions'], while reserving cycles_completed for actual
cycle transitions. Also ensure the should_recompose()/hot_swap_content() path
runs for continuous mode so live updates reach plugins already composed into the
strip without interrupting scrolling.
test/test_scroll_helper_continuous.py (1)

20-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add type hints to the test helper factories.

helper() and block() lack parameter/return type hints.

As per coding guidelines, "Use type hints for function parameters and return values."

♻️ Proposed fix
-def helper():
+def helper() -> ScrollHelper:
     return ScrollHelper(W, H)


-def block(width, colour=(255, 255, 255), height=H):
+def block(width: int, colour: tuple[int, int, int] = (255, 255, 255), height: int = H) -> Image.Image:
     return Image.new('RGB', (width, height), colour)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_scroll_helper_continuous.py` around lines 20 - 26, Add parameter
and return type annotations to the helper() and block() test factory functions,
using the appropriate types for their arguments and the ScrollHelper and Image
return values. Preserve their existing behavior and defaults.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vegas_mode/render_pipeline.py`:
- Around line 233-269: Update the background worker in start_prefetch to store
_prepared_group only when the fetched group is non-empty, leaving it as None
after exceptions or empty results so future prefetch attempts can run. In
extend_scroll_content, treat a claimed falsy group as unavailable and continue
through the existing inline-fetch fallback instead of returning False
immediately.

In `@test/test_vegas_density.py`:
- Around line 1343-1354: Update test_prepared_group_is_used_without_refetching
so it verifies the fetch behavior rather than accepting any _prepared_group
value. After extend_scroll_content(), assert stream.calls contains only the
expected offscreen prefetch and no additional non-offscreen fetch, preserving
the existing setup and join behavior.

---

Nitpick comments:
In `@src/common/scroll_helper.py`:
- Around line 651-714: The reviewer notes a potential render-thread performance
concern in append_content, but explicitly requests no change now. Leave the
np.concatenate and Image.fromarray implementation unchanged; do not add a
preallocated buffer, slack capacity, or logical-width refactor.

In `@src/display_manager.py`:
- Around line 529-537: Update the capture state used by _capture_mode_active and
capture_mode() from a boolean to a per-thread nesting depth, incrementing on
entry and decrementing on exit without allowing premature suppression release.
Keep _capture_mode_active true while depth is greater than zero, including
across nested capture scopes, and preserve the existing thread-local behavior.

In `@src/vegas_mode/coordinator.py`:
- Around line 309-324: Update the continuous-scroll branch in the coordinator
flow so successful render_pipeline.extend_scroll_content() calls do not
increment stats['cycles_completed']; track extensions separately or rely on
render_pipeline.stats['extensions'], while reserving cycles_completed for actual
cycle transitions. Also ensure the should_recompose()/hot_swap_content() path
runs for continuous mode so live updates reach plugins already composed into the
strip without interrupting scrolling.

In `@test/test_scroll_helper_continuous.py`:
- Around line 20-26: Add parameter and return type annotations to the helper()
and block() test factory functions, using the appropriate types for their
arguments and the ScrollHelper and Image return values. Preserve their existing
behavior and defaults.

In `@test/test_vegas_density.py`:
- Line 1156: Remove the redundant class-scoped contextmanager import from
RecordingDM, keeping the existing module-level import for any class methods that
use it and preventing contextmanager from becoming a RecordingDM class
attribute.
- Around line 1491-1511: Make the throttle tests deterministic by explicitly
setting extend_threshold_screens in _pipeline for
test_drains_are_spaced_when_lookahead_is_healthy and
test_urgent_drain_ignores_the_throttle, choosing values that preserve healthy
versus urgent behavior. Replace reliance on real time.time() with a
monkeypatched clock and advance it explicitly around DEFERRED_DRAIN_INTERVAL so
the expected deferred and immediate drain outcomes remain stable if defaults or
test timing change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: feb6c6e6-c37b-46ea-a161-92275fc104fb

📥 Commits

Reviewing files that changed from the base of the PR and between a1c528a and 94032e2.

📒 Files selected for processing (12)
  • config/config.template.json
  • src/common/scroll_helper.py
  • src/display_manager.py
  • src/vegas_mode/config.py
  • src/vegas_mode/coordinator.py
  • src/vegas_mode/plugin_adapter.py
  • src/vegas_mode/render_pipeline.py
  • src/vegas_mode/stream_manager.py
  • test/test_scroll_helper_continuous.py
  • test/test_vegas_density.py
  • web_interface/blueprints/api_v3.py
  • web_interface/templates/v3/partials/display.html
🚧 Files skipped from review as they are similar to previous changes (5)
  • config/config.template.json
  • web_interface/blueprints/api_v3.py
  • src/vegas_mode/stream_manager.py
  • src/vegas_mode/config.py
  • src/vegas_mode/plugin_adapter.py

Comment on lines +233 to +269
def start_prefetch(self) -> None:
"""
Begin preparing the next group in the background, if not already doing so.

This is what makes the join seamless rather than merely continuous:
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
dominates), and doing it on the render thread stalls the scroll for that
long. Off the render thread there is a whole group's scroll time to work
in, so by the time the strip needs extending the content is already sat
waiting.

Only paths that avoid the shared display canvas run here; anything
needing it is marked and picked up on the render thread, where it is
safe. Those are the cheap ones — display capture measured 12-14ms
against seconds for the native renders.
"""
if not self.config.continuous_scroll:
return

with self._prefetch_lock:
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
return
if self._prepared_group is not None:
return # already have one waiting

def _work():
try:
group = self.stream_manager.take_next_group(offscreen_only=True)
except Exception:
logger.exception("Background prefetch failed")
group = []
with self._prefetch_lock:
self._prepared_group = group

self._prefetch_thread = threading.Thread(
target=_work, daemon=True, name="vegas-strip-prefetch")
self._prefetch_thread.start()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A failed/empty prefetch wedges both the prefetcher and the extension path.

_work() assigns self._prepared_group = group even when the fetch raised or returned []. Two consequences:

  1. start_prefetch() then short-circuits on if self._prepared_group is not None: return — since [] is not None, no further prefetch is ever attempted until something claims it.
  2. extend_scroll_content() claims [], which is not None, so the inline-fetch fallback at line 361 is skipped; it falls straight to if not grouped: return False.

Net effect: one empty background fetch turns into a failed extension and a stalled prefetcher, so the strip can run dry and the scroll freezes until the cycle-complete backstop fires. Only store a non-empty group, and treat a falsy claim as "nothing prepared".

🐛 Proposed fix
             def _work():
                 try:
                     group = self.stream_manager.take_next_group(offscreen_only=True)
                 except Exception:
                     logger.exception("Background prefetch failed")
                     group = []
-                with self._prefetch_lock:
-                    self._prepared_group = group
+                if not group:
+                    # Leave the slot empty so the next start_prefetch() retries
+                    # and extend_scroll_content() falls back to an inline fetch.
+                    return
+                with self._prefetch_lock:
+                    self._prepared_group = group
             grouped = self._claim_prepared_group()
-            if grouped is None:
+            if not grouped:
                 # Nothing prepared (first extension, or prefetch still running).
                 # Fetch inline; the scroll hitches, but content keeps flowing.
                 logger.info("No prepared group ready; fetching inline")
                 grouped = self.stream_manager.take_next_group()

Also applies to: 338-343, 359-369

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vegas_mode/render_pipeline.py` around lines 233 - 269, Update the
background worker in start_prefetch to store _prepared_group only when the
fetched group is non-empty, leaving it as None after exceptions or empty results
so future prefetch attempts can run. In extend_scroll_content, treat a claimed
falsy group as unavailable and continue through the existing inline-fetch
fallback instead of returning False immediately.

Comment on lines +1343 to +1354
def test_prepared_group_is_used_without_refetching(self):
groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]]
p, stream = self._pipeline(groups, continuous_scroll=True)
p.compose_scroll_content()
p.start_prefetch()
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5)

assert p.extend_scroll_content()
# One offscreen prefetch, then one more kicked off for the group after.
assert stream.calls[0] is True
assert p._prepared_group is None or isinstance(p._prepared_group, list)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This test can never fail — the final assertion is a tautology.

p._prepared_group is None or isinstance(p._prepared_group, list) is true for every possible value, so the "used without refetching" invariant isn't actually checked. Assert on stream.calls instead (e.g. that no additional non-offscreen fetch happened during the extend).

💚 Proposed fix
         assert p.extend_scroll_content()
         # One offscreen prefetch, then one more kicked off for the group after.
         assert stream.calls[0] is True
-        assert p._prepared_group is None or isinstance(p._prepared_group, list)
+        # The extend must consume the prepared group rather than fetching inline,
+        # so no offscreen_only=False call may appear.
+        assert False not in stream.calls
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_prepared_group_is_used_without_refetching(self):
groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]]
p, stream = self._pipeline(groups, continuous_scroll=True)
p.compose_scroll_content()
p.start_prefetch()
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5)
assert p.extend_scroll_content()
# One offscreen prefetch, then one more kicked off for the group after.
assert stream.calls[0] is True
assert p._prepared_group is None or isinstance(p._prepared_group, list)
def test_prepared_group_is_used_without_refetching(self):
groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]]
p, stream = self._pipeline(groups, continuous_scroll=True)
p.compose_scroll_content()
p.start_prefetch()
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5)
assert p.extend_scroll_content()
# One offscreen prefetch, then one more kicked off for the group after.
assert stream.calls[0] is True
# The extend must consume the prepared group rather than fetching inline,
# so no offscreen_only=False call may appear.
assert False not in stream.calls
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 1343 - 1354, Update
test_prepared_group_is_used_without_refetching so it verifies the fetch behavior
rather than accepting any _prepared_group value. After extend_scroll_content(),
assert stream.calls contains only the expected offscreen prefetch and no
additional non-offscreen fetch, preserving the existing setup and join behavior.

ChuckBuilds and others added 3 commits July 29, 2026 14:39
The loop slept a fixed frame_interval on top of however long the frame took, so
at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of
the budget spent not rendering. It now sleeps only the remainder of the budget.

Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from
150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50,
because motion is derived from elapsed time rather than frame count — this buys
smoothness, not speed.

Worth recording what the bottleneck was not: the per-frame render path measures
0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking
digest), which is a theoretical 2900 fps. Optimising any of that would have been
wasted effort. The frame was idle, not busy.

Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL,
so the scheduler can act on the priority, and without it the prefetch competes
for the same cores as the render loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
With integer positioning the number of distinct frames per second equals the
scroll speed in px/s, however fast the loop renders. Measured at 50px/s and
78.7fps, 36% of frames were byte-identical: the extra frames cost work and
bought no motion, and what was left was 50 discrete 1px steps a second.

Two things were wrong with the pre-existing sub-pixel support. get_visible_portion
never consulted sub_pixel_scrolling — it always took the integer path, so the flag
and _get_visible_portion_subpixel were dead code. And that implementation needed
scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is
False there), so it would not have interpolated even if reached. Verified both:
positions 1000.0 and 1000.5 produced identical frames either way.

Blending is now wired up and implemented with numpy. Two details make it
affordable: slice cached_array directly instead of building two PIL images only
to convert them straight back (the naive version measured 15x the integer path),
and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's
cores and gives finer weighting than the panel can resolve. Result 0.939ms
against 0.237ms — 0.70ms added per frame, a 1065fps ceiling.

Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no
cost within noise — and every frame is now a distinct position rather than one in
three being a repeat.

The trade is a slight horizontal softening of text, since each frame blends two
positions. Set smooth_scroll false for maximum crispness.

Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an
11,000px strip and trimming 2.5ms, both under one frame at this rate.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
… a window

The width budget split any oversized plugin by advancing a window each cycle.
That is right for interchangeable items — news headlines, odds, stock prices —
but wrong for ordered content: a league table showed ranks 1-6, then resumed at
7 two rotations later, which reads as out of order and out of context. Nobody
needs rank 23 in a ticker; they need the top of the table, every time.

overflow_mode chooses between them:

  rotate   — advance a window each cycle so everything is seen eventually
             (unchanged default)
  truncate — always show the start and drop the rest, keeping ordered content
             coherent. Records no window state, so every pass starts at the top.

Per-plugin vegas_overflow overrides the global setting, since one install has
both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content
that must stay whole can be given more room — or uncapped with 0 — without
lifting the cap on every ticker.

Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to
truncate, and baseball given 4.5 screens because it was showing 8 of 9 games
when the whole slate needed only a little more room. Verified: F1 now reports
"the first 10 of 116 ... the rest are not shown", baseball has dropped out of
the budget log entirely, and stocks, odds-ticker and stock-news still rotate.

Also corrects the crop log, which claimed "window advances next cycle"
unconditionally and so misreported truncated crops. A test now pins the
behaviour behind the message: truncate must leave no offset recorded.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/test_vegas_density.py (1)

1521-1577: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Apply the repository’s Python test conventions. Add concise docstrings to the Cfg stubs and TestPerPluginWidthBudget, and annotate fixture/test methods (including cfg, bad, and -> None return types).

  • test/test_vegas_density.py#L1521-L1577: document and type-annotate TestOverflowMode.Cfg and its test methods.
  • test/test_vegas_density.py#L1580-L1639: add the missing suite docstring and annotate TestPerPluginWidthBudget.Cfg and its test methods.

As per coding guidelines, “Use docstrings for classes and complex functions” and “Use type hints for function parameters and return values.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 1521 - 1577, In
test/test_vegas_density.py:1521-1577, add concise docstrings to
TestOverflowMode.Cfg and annotate its fixture/test methods, including cfg, bad,
and -> None; in test/test_vegas_density.py:1580-1639, add the
TestPerPluginWidthBudget suite docstring, document its Cfg stub, and add
parameter and return type annotations to all fixture/test methods. Preserve
existing test behavior.

Source: Coding guidelines

src/common/scroll_helper.py (1)

745-773: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the strip-growing allocation against MemoryError.

np.concatenate + Image.fromarray allocate a new buffer the size of the whole (growing) strip. On a RAM-constrained Raspberry Pi, an OOM here isn't caught by the caller (extend_scroll_content() only catches ValueError, TypeError, OSError, RuntimeError), so it would propagate out of the render loop.

♻️ Proposed fix for graceful degradation on OOM
-        self.cached_array = np.concatenate(
-            (self.cached_array, np.array(addition)), axis=1)
-        self.cached_image = Image.fromarray(self.cached_array)
+        try:
+            new_array = np.concatenate(
+                (self.cached_array, np.array(addition)), axis=1)
+            new_image = Image.fromarray(new_array)
+        except MemoryError:
+            self.logger.error(
+                "Out of memory appending %dpx to scroll strip (current %dpx); skipping append",
+                addition_width, self.total_scroll_width)
+            return False
+        self.cached_array = new_array
+        self.cached_image = new_image
         self.total_scroll_width = self.cached_image.width
         self.scroll_complete = False

As per coding guidelines, "Optimize code for Raspberry Pi's limited RAM and CPU capabilities" and "Implement graceful degradation to continue operation when non-critical features fail."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/common/scroll_helper.py` around lines 745 - 773, Guard the strip-growing
allocation in the append flow around `np.concatenate` and `Image.fromarray` with
`MemoryError` handling. On allocation failure, log the failure, leave the
existing cached strip and scroll state unchanged, and return `False` so
`extend_scroll_content()` can continue gracefully.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/test_vegas_density.py`:
- Around line 1547-1559: Strengthen the truncate fixtures so shifted windows
produce different results: in test/test_vegas_density.py lines 1547-1559,
replace identical rows with uniquely marked rows and assert both get_content
calls return the expected leading rows; in lines 1569-1577, replace the uniform
strip with position markers and assert the crop starts at the same marker after
invalidation.

---

Nitpick comments:
In `@src/common/scroll_helper.py`:
- Around line 745-773: Guard the strip-growing allocation in the append flow
around `np.concatenate` and `Image.fromarray` with `MemoryError` handling. On
allocation failure, log the failure, leave the existing cached strip and scroll
state unchanged, and return `False` so `extend_scroll_content()` can continue
gracefully.

In `@test/test_vegas_density.py`:
- Around line 1521-1577: In test/test_vegas_density.py:1521-1577, add concise
docstrings to TestOverflowMode.Cfg and annotate its fixture/test methods,
including cfg, bad, and -> None; in test/test_vegas_density.py:1580-1639, add
the TestPerPluginWidthBudget suite docstring, document its Cfg stub, and add
parameter and return type annotations to all fixture/test methods. Preserve
existing test behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 61621d88-37da-46b6-8854-9a9f8698383d

📥 Commits

Reviewing files that changed from the base of the PR and between 94032e2 and a413849.

📒 Files selected for processing (10)
  • config/config.template.json
  • src/common/scroll_helper.py
  • src/vegas_mode/config.py
  • src/vegas_mode/coordinator.py
  • src/vegas_mode/plugin_adapter.py
  • src/vegas_mode/render_pipeline.py
  • test/test_scroll_helper_continuous.py
  • test/test_vegas_density.py
  • web_interface/blueprints/api_v3.py
  • web_interface/templates/v3/partials/display.html
🚧 Files skipped from review as they are similar to previous changes (6)
  • config/config.template.json
  • src/vegas_mode/coordinator.py
  • web_interface/blueprints/api_v3.py
  • src/vegas_mode/render_pipeline.py
  • src/vegas_mode/plugin_adapter.py
  • src/vegas_mode/config.py

Comment on lines +1547 to +1559
def test_truncate_always_shows_the_same_opening_rows(self):
# The reported problem: a ranked list should not resume from the middle.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0,
overflow_mode='truncate')
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
plugin = NativePlugin(rows)

first = adapter.get_content(plugin, 'ranks')
adapter.invalidate_cache('ranks')
second = adapter.get_content(plugin, 'ranks')
assert len(first) == len(second)
assert 'ranks' not in adapter._item_offsets, "must not advance a window"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the truncate fixtures distinguish different windows. The all-ink rows and strip make a shifted window produce the same count and bytes, so these tests can pass if truncation starts at the wrong position or advances.

  • test/test_vegas_density.py#L1547-L1559: use uniquely marked rows and assert both calls return the expected leading rows.
  • test/test_vegas_density.py#L1569-L1577: use a non-uniform strip (for example, position markers) and assert the returned crop begins at the same marker after invalidation.
📍 Affects 1 file
  • test/test_vegas_density.py#L1547-L1559 (this comment)
  • test/test_vegas_density.py#L1569-L1577
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_vegas_density.py` around lines 1547 - 1559, Strengthen the truncate
fixtures so shifted windows produce different results: in
test/test_vegas_density.py lines 1547-1559, replace identical rows with uniquely
marked rows and assert both get_content calls return the expected leading rows;
in lines 1569-1577, replace the uniform strip with position markers and assert
the crop starts at the same marker after invalidation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant